</> 技術筆記Tech Notes

Serenity 整合 Vue3 完整指南 - 以 OPPM 專案管理為例

Serenity 整合 Vue3 完整指南 - 以 OPPM 專案管理為例

本文將介紹如何在 Serenity 專案中 整合 Vue3 所開發的應用程式,包含環境設定、CSRF Token 處理、API 設計、前端封裝和實際應用等完整流程。

一、前言:為何選擇 Vue 開發並整合到 Serenity?

  1. 技術互補性

    • Serenity 優勢: 強大的後端框架、完整的權限管理、資料庫 ORM

    • Vue 優勢: 現代化前端體驗、豐富的生態系統、靈活的元件化開發

  2. 開發效率考量

    // Vue 的響應式開發效率
    const { tasks, updateTask } = useOppmData()
    
    // 簡潔的資料綁定和狀態管理
    const filteredTasks = computed(() => {
      return tasks.filter(task => 
        task.name.includes(searchQuery.value)
      )
    })
    
  3. 使用者體驗提升

    • 即時互動: Vue 的響應式系統提供即時的 UI 更新

    • 豐富元件: FullCalendar、拖拽看板等現代化 UI 元件

    • 漸進式載入: SPA 架構減少頁面重新載入

  4. 維護性優勢

    • 關注點分離: 前端專注於 UI/UX,後端專注於業務邏輯

    • 獨立測試: 前後端可分別進行單元測試

    • 技術棧現代化: 使用 Vue 3、Vite 等現代工具鏈

  5. 整合策略

    // 透過 API 與 Serenity 後端整合
    export const apiClient = createApiClient()
    
    // 利用 Serenity 的認證和權限系統
    const user = serenityIntegration.getCurrentUser()
    const hasPermission = serenityIntegration.hasPermission('kanban:write')
    
  6. 擴展性考慮

    • 微前端架構: Vue 應用可作為 Serenity 頁面的一部分

    • 漸進式採用: 可逐步將 Serenity 頁面轉換為 Vue 元件

    • 混合開發: 關鍵業務邏輯保持在 Serenity,UI 創新使用 Vue

二、現有 Vue 功能說明與 Domain Model

  1. OPPM (One Page Project Management) 系統概述 OPPM 是一個一頁式專案管理系統,整合了甘特圖、看板、月曆和表格等多種專案檢視模式。該 Vue 3 應用程式提供以下核心功能: 主要功能特色:

    • 多檢視模式:

      • 甘特圖檢視 (Gantt):時間軸展示任務進度

      • 看板檢視 (Kanban):敏捷開發工作流程管理

      • 月曆檢視 (Calendar):時間導向的任務檢視

      • 表格檢視 (Table):詳細的資料列表模式

    • 專案標頭管理:

      • 專案經理、名稱、目的等基本資訊

      • 支援即時編輯功能 (點擊編輯)

      • 鍵盤快速鍵支援 (Enter 確認、Escape 取消)

    • 月曆整合:

      • 使用 FullCalendar 提供專業的日曆功能

      • 任務狀態色彩編碼

      • 月檢視和週檢視切換

      • 中文化界面

    • 互動功能:

      • 任務搜尋和篩選

      • 多選任務操作

      • 拖拽排序 (部分檢視)

      • 內聯編輯

  2. Domain Model (資料模型) 基於 useOppmData.js 的專案資料結構:

    1. 核心實體 (Core Entities)

      1. 專案 (Projects)

        {
          "id": "proj-001",
          "name": "優化物流請款作業"
        }
        
      2. 專案資訊 (Project Info)

        {
          "name": "優化物流請款作業",
          "purpose": "透過系統化與自動化,簡化請款流程,降低人工作業錯誤率,並加速應收帳款回收週期。",
          "benefits": "預期能節省 50% 的人工對帳時間,並將帳款回收天數 (DSO) 縮短 5 天。",
          "projectManager": "陳經理"
        }
        
      3. 任務 (Tasks)

        {
          "id": 1,
          "name": "專案啟動會議",
          "startDate": "2025-05-05",
          "endDate": "2025-05-06",
          "status": "completed",
          "ownerIds": [101],
          "relatedGoalIds": [1, 2, 3],
          "kanbanColumnId": 4,
          "priority": "high"
        }
        
      4. 專案成員 (People)

        {
          "id": 101,
          "name": "陳經理",
          "role": "PM"
        }
        
      5. 專案目標 (Goals)

        {
          "id": 1,
          "name": "流程自動化"
        }
        
      6. 看板欄位 (Kanban Columns)

        {
          "id": 1,
          "title": "待辦事項 (To Do)",
          "color": "#ebecf0"
        }
        
      7. 評論 (Comments)

        {
          "id": "c1",
          "taskId": 1,
          "authorId": 101,
          "text": "會議記錄已發送,請查收。",
          "timestamp": "2025-05-06T14:30:00"
        }
        
      8. 風險 (Risks)

        {
          "id": "A",
          "title": "A. 資訊安全風險"
        }
        
      9. 風險狀態追蹤 (Risk Status Data)

        {
          "B": {
            "2025-09-21": "red",
            "2025-09-28": "red"
          }
        }
        
    2. 業務規則 (Business Rules)

      • 任務狀態管理:

        • completed:已完成任務 (綠色)

        • critical:關鍵任務 (紅色)

        • planned:計劃中任務 (藍色)

        • future:未來任務 (灰色)

      • 看板工作流程:

        • 待辦事項 → 進行中 → 待審核 → 已完成

        • 支援自訂欄位和顏色

      • 時間軸設定:

        • 專案開始:2025-05-04

        • 專案結束:2026-02-01

      • 優先級管理:

        • high:高優先級

        • medium:中等優先級

        • low:低優先級

三、環境設定:開發和生產環境

  1. Vite 配置調整 首先調整 Vite 配置以支援開發和生產環境的不同需求:

    // vite.config.js
    import {
        defineConfig
    }
    from 'vite'
    import vue from '@vitejs/plugin-vue'
    import {
        resolve
    }
    from 'path'
    
    export default defineConfig(({
            command,
            mode
        }) => {
        const isDev = command === 'serve'
            const isProd = mode === 'production'
    
            return {
            plugins: [vue()],
            base: isProd ? '/Content/dist/' : '/',
            build: {
                outDir: 'dist',
                assetsDir: 'assets',
                sourcemap: isDev,
                rollupOptions: {
                    input: {
                        main: resolve(__dirname, 'index.html')
                    },
                    output: {
                        // 為了與 Serenity 整合,確保資源路徑正確
                        assetFileNames: 'assets/[name].[hash][extname]',
                        chunkFileNames: 'assets/[name].[hash].js',
                        entryFileNames: 'assets/[name].[hash].js'
                    }
                }
            },
            server: {
                port: 3000,
                proxy: {
                    // 開發環境代理 API 請求到 Serenity 後端
                    '/api': {
                        target: 'https://localhost:44300', // 調整為您的 Serenity 開發伺服器地址
                        changeOrigin: true,
                        secure: false,
                        rewrite: (path) => path.replace(/^\/api/, '/Services')
                    }
                }
            },
            define: {
                // 環境變數
                __DEV__: isDev,
                __API_BASE__: isDev
                 ? JSON.stringify('http://localhost:3000/api')
                 : JSON.stringify('/Services')
            }
        }
    })
    
  2. 環境配置文件 創建不同環境的配置文件:

    Bash

    # .env.development
    VITE_APP_TITLE=OPPM Development
    VITE_API_BASE_URL=http://localhost:3000/api
    VITE_SERENITY_BASE_URL=https://localhost:44300
    
    # .env.production
    VITE_APP_TITLE=OPPM Production
    VITE_API_BASE_URL=/Services
    VITE_SERENITY_BASE_URL=
    
  3. 打包腳本設定 更新 package.json 添加打包和部署腳本:

    {
      "scripts": {
        "dev": "vite",
        "build": "vite build",
        "build:dev": "vite build --mode development",
        "preview": "vite preview",
        "build:serenity": "npm run build && npm run copy-to-serenity",
        "copy-to-serenity": "node scripts/copy-dist.js"
      }
    }
    
  4. 自動化部署腳本 創建自動複製到 Serenity 專案的腳本:

    // scripts/copy-dist.js
    const fs = require('fs-extra')
    const path = require('path')
    
    const distPath = path.resolve(__dirname, '../dist')
    const serenityContentPath = path.resolve(__dirname, '../../../YourSerenityProject/Content/dist') // 調整為實際路徑
    
    async function copyDistToSerenity() {
      try {
        // 確保目標目錄存在
        await fs.ensureDir(serenityContentPath)
    
        // 複製 dist 目錄到 Serenity 專案
        await fs.copy(distPath, serenityContentPath, {
          overwrite: true,
          filter: (src) => {
            // 過濾不需要的文件
            return !src.includes('.map') || process.env.NODE_ENV !== 'production'
          }
        })
    
        console.log(' 成功複製 dist 到 Serenity 專案')
        console.log(`目標路徑: ${serenityContentPath}`)
      } catch (error) {
        console.error('複製失敗:', error)
        process.exit(1)
      }
    }
    
    copyDistToSerenity()
    

四、CSRF Token 處理

  1. 什麼是 CSRF Token?

    • CSRF (Cross-Site Request Forgery) 跨站請求偽造

    • CSRF 是一種網路攻擊手法,攻擊者誘導受害者在已登入的網站上執行非預期的操作。

    • 攻擊流程示例:

      1. 使用者登入銀行網站 bank.com

      2. 攻擊者製作惡意網頁,包含轉帳請求: <img src="https://bank.com/transfer?to=attacker&amount=1000" />

      3. 使用者訪問惡意網頁時,瀏覽器自動發送轉帳請求

      4. 由於使用者仍處於登入狀態,銀行網站執行轉帳操作

    • CSRF Token 的作用機制

      • CSRF Token 是一個隨機、唯一且不可預測的值,用於驗證請求是否來自合法來源:

    • 防護流程:

      1. 伺服器生成 Token: 為每個會話或表單生成唯一的 CSRF Token

      2. 嵌入表單: 將 Token 嵌入 HTML 表單或透過 API 提供

      3. 驗證請求: 伺服器檢查每個非安全請求(POST、PUT、DELETE)是否包含有效的 Token

      4. 拒絕無效請求: 沒有 Token 或 Token 無效的請求被拒絕

    • 為什麼有效?

      • 攻擊者無法取得受害者頁面中的 CSRF Token

      • 同源政策阻止跨域腳本讀取 Token 值

      • 每個 Token 都是唯一且時效性的

  2. Serenity 框架中的 CSRF 設定

    1. 啟用 CSRF 保護 在 Serenity 專案中啟用 CSRF 保護:

      // Startup.cs 或 Program.cs
      public void ConfigureServices(IServiceCollection services)
      {
          // 啟用防偽造 Token
          services.AddAntiforgery(options =>
          {
              options.HeaderName = "X-CSRF-Token"; // 自訂 Header 名稱
              options.Cookie.Name = "__RequestVerificationToken"; // Cookie 名稱
              options.Cookie.HttpOnly = true; // 防止 JavaScript 存取
              options.Cookie.SameSite = SameSiteMode.Strict; // 嚴格同站政策
              options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HTTPS only
              options.RequireSsl = true; // 需要 SSL
          });
      
          // Serenity 相關設定
          services.AddSerenity(Configuration);
      }
      
      public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
      {
          // ... 其他中介軟體 ...
      
          // 啟用防偽造驗證
          app.UseRouting();
          app.UseAuthentication();
          app.UseAuthorization();
      
          // ... 其他設定 ...
      }
      
    2. 創建 CSRF Token 提供者 建立專門的 API 端點來提供 CSRF Token:

      // Controllers/CSRFController.cs
      [ApiController]
      [Route("api/[controller]")]
      public class CSRFController : ControllerBase
      {
          private readonly IAntiforgery _antiforgery;
      
          public CSRFController(IAntiforgery antiforgery)
          {
              _antiforgery = antiforgery;
          }
      
          /// <summary>
          /// 獲取 CSRF Token
          /// </summary>
          [HttpGet("token")]
          public IActionResult GetCSRFToken()
          {
              var tokens = _antiforgery.GetAndStoreTokens(HttpContext);
      
              return Ok(new
              {
                  token = tokens.RequestToken,
                  headerName = "X-CSRF-Token"
              });
          }
      }
      
    3. 在 Controller 中啟用 CSRF 驗證 為需要保護的 API Controller 添加 CSRF 驗證:

      // Controllers/KanbanController.cs
      [ApiController]
      [Route("api/[controller]")]
      [ValidateAntiForgeryToken] // 全 Controller 啟用
      public class KanbanController : ControllerBase
      {
          // ... 現有代碼 ...
      
          [HttpPost("columns")]
          [ValidateAntiForgeryToken] // 特定 Action 啟用
          public async Task<ActionResult<ApiResponse<KanbanColumn>>> CreateColumn([FromBody] CreateKanbanColumnDto dto)
          {
              // ... 實作邏輯 ...
          }
      
          [HttpPut("columns/{id}")]
          [ValidateAntiForgeryToken]
          public async Task<ActionResult<ApiResponse<KanbanColumn>>> UpdateColumn(int id, [FromBody] UpdateKanbanColumnDto dto)
          {
              // ... 實作邏輯 ...
          }
      
          [HttpDelete("columns/{id}")]
          [ValidateAntiForgeryToken]
          public async Task<ActionResult<ApiResponse<bool>>> DeleteColumn(int id)
          {
              // ... 實作邏輯 ...
          }
      }
      
    4. 在 Serenity 頁面中嵌入 Token 更新 Serenity 頁面模板以提供 CSRF Token:

      @{
          ViewData["Title"] = "OPPM 看板";
          // 生成防偽造 Token
          var antiforgeryTokenSet = Html.AntiForgeryToken();
      }
      
      <div id="oppm-app"></div>
      
      <form id="csrf-form" style="display: none;">
          @Html.AntiForgeryToken()
      </form>
      
      <meta name="csrf-token" content="@Html.AntiForgeryToken().ToString()">
      
      <script>
          window.csrfToken = '@Html.AntiForgeryToken().ToString()';
          window.csrfHeaderName = 'X-CSRF-Token';
      </script>
      
      @section Head {
          <link rel="stylesheet" href="~/Content/dist/assets/index.css" />
      }
      
      @section Scripts {
          <script type="module" src="~/Content/dist/assets/index.js"></script>
      }
      
  3. Vue CSRF Token 管理器 建立完整的 CSRF Token 管理機制:

    // src/utils/apiConfig.js
    const isDevelopment = import.meta.env.DEV
    const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || (isDevelopment ? '/api' : '/Services')
    
    export const API_CONFIG = {
      baseURL: apiBaseUrl,
      timeout: 10000,
      headers: {
        'Content-Type': 'application/json',
      }
    }
    
    /
     * CSRF Token 管理器
     * 負責獲取、快取和管理 CSRF Token
     */
    class CSRFTokenManager {
      constructor() {
        this.token = null
        this.tokenExpiry = null
        this.refreshPromise = null // 防止並發請求
      }
    
      /
       * 獲取有效的 CSRF Token
       * @returns {Promise<string|null>} CSRF Token
       */
      async getToken() {
        // 如果 token 還有效,直接返回
        if (this.token && this.tokenExpiry && Date.now() < this.tokenExpiry) {
          return this.token
        }
    
        // 如果正在刷新 token,等待完成
        if (this.refreshPromise) {
          return await this.refreshPromise
        }
    
        // 開始刷新 token
        this.refreshPromise = this._refreshToken()
    
        try {
          const token = await this.refreshPromise
          return token
        } finally {
          this.refreshPromise = null
        }
      }
    
      /
       * 刷新 CSRF Token
       * @private
       */
      async _refreshToken() {
        try {
          // 方法 1: 從專用 API 端點獲取
          const response = await fetch('/api/csrf/token', {
            method: 'GET',
            credentials: 'same-origin'
          })
    
          if (response.ok) {
            const data = await response.json()
            this.token = data.token
            this.tokenExpiry = Date.now() + (30 * 60 * 1000) // 30 分鐘
            return this.token
          }
        } catch (error) {
          console.warn('從 API 端點獲取 CSRF Token 失敗:', error)
        }
    
        // 方法 2: 從頁面元素獲取
        const pageToken = this._getTokenFromPage()
        if (pageToken) {
          this.token = pageToken
          this.tokenExpiry = Date.now() + (30 * 60 * 1000)
          return this.token
        }
    
        // 方法 3: 從任意 Serenity 端點獲取(最後手段)
        try {
          const response = await fetch('/Services/Administration/UserPermission/List', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({}),
            credentials: 'same-origin'
          })
    
          // 從響應頭獲取 token
          const token = response.headers.get('X-CSRF-Token') || 
                       response.headers.get('RequestVerificationToken')
    
          if (token) {
            this.token = token
            this.tokenExpiry = Date.now() + (30 * 60 * 1000)
            return this.token
          }
        } catch (error) {
          console.error('從 Serenity 端點獲取 CSRF Token 失敗:', error)
        }
    
        throw new Error('無法獲取 CSRF Token')
      }
    
      /
       * 從頁面元素獲取 CSRF Token
       * @private
       */
      _getTokenFromPage() {
        // 從隱藏表單欄位獲取
        const tokenInput = document.querySelector('input[name="__RequestVerificationToken"]')
        if (tokenInput && tokenInput.value) {
          return tokenInput.value
        }
    
        // 從 meta 標籤獲取
        const metaToken = document.querySelector('meta[name="csrf-token"]')
        if (metaToken) {
          const content = metaToken.getAttribute('content')
          if (content && content !== '@Html.AntiForgeryToken().ToString()') {
            return content
          }
        }
    
        // 從全域變數獲取
        if (window.csrfToken && window.csrfToken !== '@Html.AntiForgeryToken().ToString()') {
          return window.csrfToken
        }
    
        // 從 Serenity 的全域物件獲取
        if (window.Q && window.Q.Authorization && window.Q.Authorization.antiForgeryToken) {
          return window.Q.Authorization.antiForgeryToken
        }
    
        return null
      }
    
      /
       * 清除快取的 token(登出或錯誤時調用)
       */
      clearToken() {
        this.token = null
        this.tokenExpiry = null
        this.refreshPromise = null
      }
    }
    
    const csrfManager = new CSRFTokenManager()
    
    /
     * API 請求客戶端
     * 自動處理 CSRF Token 和錯誤重試
     */
    export const createApiClient = () => {
      const request = async (url, options = {}) => {
        const fullUrl = `${API_CONFIG.baseURL}${url}`
    
        let headers = { ...API_CONFIG.headers, ...options.headers }
    
        if (options.method && options.method !== 'GET') {
          try {
            const csrfToken = await csrfManager.getToken()
            if (csrfToken) {
              headers['X-CSRF-Token'] = csrfToken
              headers['RequestVerificationToken'] = csrfToken
            }
          } catch (error) {
            console.error('CSRF Token 獲取失敗:', error)
          }
        }
    
        const config = { ...options, headers, credentials: 'same-origin' }
    
        try {
          const response = await fetch(fullUrl, config)
    
          if (response.status === 400 || response.status === 403) {
            const responseText = await response.text()
    
            if (responseText.includes('RequestVerificationToken') || responseText.includes('CSRF')) {
              console.warn('CSRF Token 驗證失敗,嘗試重新獲取並重試')
              csrfManager.clearToken()
    
              try {
                const newToken = await csrfManager.getToken()
                if (newToken) {
                  headers['X-CSRF-Token'] = newToken
                  headers['RequestVerificationToken'] = newToken
                  const retryResponse = await fetch(fullUrl, { ...config, headers })
                  if (!retryResponse.ok) throw new Error(`HTTP error! status: ${retryResponse.status}`)
                  return await retryResponse.json()
                }
              } catch (retryError) {
                console.error('重試請求失敗:', retryError)
                throw new Error('CSRF Token 驗證失敗且重試失敗')
              }
            }
          }
    
          if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
          return await response.json()
        } catch (error) {
          console.error('API request failed:', error)
          throw error
        }
      }
    
      return {
        get: (url, options) => request(url, { method: 'GET', ...options }),
        post: (url, data, options) => request(url, { method: 'POST', body: JSON.stringify(data), ...options }),
        put: (url, data, options) => request(url, { method: 'PUT', body: JSON.stringify(data), ...options }),
        delete: (url, options) => request(url, { method: 'DELETE', ...options }),
      }
    }
    
    export const apiClient = createApiClient()
    
  4. Serenity 整合工具 建立與 Serenity 框架深度整合的工具類:

    // src/utils/serenityIntegration.js
    
    /
     * Serenity 框架整合工具
     * 提供與 Serenity 環境的無縫整合功能
     */
    export class SerenityIntegration {
      constructor() {
        this.isEmbedded = this.checkIfEmbedded()
      }
    
      /
       * 檢查是否在 Serenity 環境中運行
       * @returns {boolean} 是否在 Serenity 環境中
       */
      checkIfEmbedded() {
        return typeof window !== 'undefined' && (
          window.Q !== undefined ||
          document.querySelector('script[src*="Serenity"]') !== null
        )
      }
    
      /
       * 獲取當前用戶資訊(從 Serenity)
       * @returns {Object|null} 用戶資訊
       */
      getCurrentUser() {
        if (window.Q && window.Q.Authorization) {
          return {
            username: window.Q.Authorization.username,
            displayName: window.Q.Authorization.userDisplayName,
          }
        }
        return null
      }
    
      /
       * 檢查用戶權限
       * @param {string} permission - 權限名稱
       * @returns {boolean} 是否擁有權限
       */
      hasPermission(permission) {
        return window.Q?.Authorization.hasPermission(permission) ?? false
      }
    
      /
       * 顯示 Serenity 樣式的通知
       * @param {string} message - 通知訊息
       * @param {string} type - 通知類型 ('info'|'success'|'warning'|'error')
       */
      showNotification(message, type = 'info', options = {}) {
        if (window.Q && window.Q.notifyInfo) {
          const notifyMethod = {
            'info': window.Q.notifyInfo,
            'success': window.Q.notifySuccess,
            'warning': window.Q.notifyWarning,
            'error': window.Q.notifyError
          }[type] || window.Q.notifyInfo
    
          notifyMethod(message, options.title, options)
        } else {
          alert(`[${type.toUpperCase()}] ${message}`)
        }
      }
    }
    
    export const serenityIntegration = new SerenityIntegration()
    
    export const PERMISSIONS = {
      KANBAN_READ: 'kanban:read',
      KANBAN_WRITE: 'kanban:write',
      ADMIN: 'admin:*'
    }
    

五、C# API 的定義

  1. 數據模型 (Models) 和 DTO

    // Models/Kanban.cs
    public class KanbanColumn
    {
        public int Id { get; set; }
        public string Title { get; set; }
        public string Color { get; set; }
        public int Order { get; set; }
    }
    
    public class Task
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime StartDate { get; set; }
        public DateTime EndDate { get; set; }
        public string Status { get; set; }
        public int KanbanColumnId { get; set; }
        public List<int> OwnerIds { get; set; }
    }
    
    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
    
    // DTOs/KanbanDtos.cs
    public class CreateKanbanColumnDto
    {
        [Required, StringLength(255)]
        public string Title { get; set; }
    
        [Required, RegularExpression(@"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")]
        public string Color { get; set; }
    }
    
    public class UpdateTaskKanbanStatusDto
    {
        [Required]
        public int KanbanColumnId { get; set; }
    }
    
    // DTOs/ResponseDtos.cs
    public class ApiResponse<T>
    {
        public bool Success { get; set; }
        public string Message { get; set; }
        public T Data { get; set; }
        public List<string> Errors { get; set; }
    }
    
    public class KanbanBoardDto
    {
        public List<KanbanColumn> Columns { get; set; }
        public List<Task> Tasks { get; set; }
        public List<Person> People { get; set; }
    }
    
  2. API Controller

    // Controllers/KanbanController.cs
    [ApiController]
    [Route("api/[controller]")]
    [ValidateAntiForgeryToken]
    public class KanbanController : ControllerBase
    {
        private readonly IKanbanService _kanbanService;
    
        public KanbanController(IKanbanService kanbanService)
        {
            _kanbanService = kanbanService;
        }
    
        [HttpGet]
        public async Task<ActionResult<ApiResponse<KanbanBoardDto>>> GetKanbanBoard()
        {
            var data = await _kanbanService.GetKanbanBoardAsync();
            return Ok(new ApiResponse<KanbanBoardDto> { Success = true, Data = data });
        }
    
        [HttpPost("columns")]
        public async Task<ActionResult<ApiResponse<KanbanColumn>>> CreateColumn([FromBody] CreateKanbanColumnDto dto)
        {
            var column = await _kanbanService.CreateColumnAsync(dto);
            return Ok(new ApiResponse<KanbanColumn> { Success = true, Data = column });
        }
    
        [HttpPut("tasks/{taskId}/kanban-status")]
        public async Task<ActionResult<ApiResponse<Task>>> UpdateTaskKanbanStatus(int taskId, [FromBody] UpdateTaskKanbanStatusDto dto)
        {
            var task = await _kanbanService.UpdateTaskKanbanStatusAsync(taskId, dto.KanbanColumnId);
            return Ok(new ApiResponse<Task> { Success = true, Data = task });
        }
    }
    
  3. 服務介面

    // Services/IKanbanService.cs
    public interface IKanbanService
    {
        Task<KanbanBoardDto> GetKanbanBoardAsync();
        Task<KanbanColumn> CreateColumnAsync(CreateKanbanColumnDto dto);
        Task<Task> UpdateTaskKanbanStatusAsync(int taskId, int kanbanColumnId);
    }
    

六、Vue 的 API Wrapper

  1. API 服務基礎類

    // src/services/BaseApiService.js
    import { apiClient } from '../utils/apiConfig'
    import { serenityIntegration } from '../utils/serenityIntegration'
    
    export class BaseApiService {
      constructor(baseEndpoint = '') {
        this.baseEndpoint = baseEndpoint
        this.client = apiClient
      }
    
      async handleApiCall(apiCall, operation, showSuccessNotification = false) {
        try {
          const response = await apiCall
    
          if (response.success) {
            if (showSuccessNotification && response.message) {
              serenityIntegration.showNotification(response.message, 'success')
            }
            return response.data
          } else {
            throw new Error(response.message || `${operation}失敗`)
          }
        } catch (error) {
          this.handleError(error, operation)
          throw error
        }
      }
    
      handleError(error, operation) {
        console.error(`${operation} 失敗:`, error)
        const message = error.message || '未知錯誤'
        serenityIntegration.showNotification(`${operation}失敗: ${message}`, 'error')
      }
    
      buildEndpoint(endpoint) {
        return `${this.baseEndpoint}${endpoint}`
      }
    }
    
  2. Kanban API 服務

    // src/services/KanbanApiService.js
    import { BaseApiService } from './BaseApiService'
    
    class KanbanApiService extends BaseApiService {
      constructor() {
        super('/kanban')
      }
    
      async getKanbanBoard() {
        return this.handleApiCall(
          this.client.get(this.baseEndpoint),
          '獲取看板數據'
        )
      }
    
      async createColumn(columnData) {
        return this.handleApiCall(
          this.client.post(this.buildEndpoint('/columns'), columnData),
          '新增看板欄位',
          true
        )
      }
    
      async updateTaskKanbanStatus(taskId, kanbanColumnId) {
        return this.handleApiCall(
          this.client.put(this.buildEndpoint(`/tasks/${taskId}/kanban-status`), { kanbanColumnId }),
          '更新任務看板狀態'
        )
      }
    }
    
    export const kanbanApiService = new KanbanApiService()
    
  3. API 服務統一導出

    // src/services/index.js
    export { kanbanApiService } from './KanbanApiService'
    
    export const apiServices = {
      kanban: kanbanApiService,
    }
    
    export const API_STATUS = {
      IDLE: 'idle',
      LOADING: 'loading',
      SUCCESS: 'success',
      ERROR: 'error'
    }
    

七、現有程式改成使用 API

  1. 更新 composable

    // src/composables/useOppmData.js
    import { ref, reactive, computed } from 'vue'
    import { apiServices, API_STATUS } from '../services'
    
    export function useOppmData() {
      const apiStatus = ref(API_STATUS.IDLE)
      const error = ref(null)
    
      const oppmData = reactive({ tasks: [], people: [] })
      const kanbanColumns = ref([])
    
      const isLoading = computed(() => apiStatus.value === API_STATUS.LOADING)
    
      const setApiStatus = (status, errorMessage = null) => {
        apiStatus.value = status
        error.value = errorMessage
      }
    
      const initializeData = async () => {
        try {
          setApiStatus(API_STATUS.LOADING)
          const boardData = await apiServices.kanban.getKanbanBoard()
          if (boardData) {
            kanbanColumns.value = boardData.columns || []
            oppmData.tasks = boardData.tasks || []
            oppmData.people = boardData.people || []
          }
          setApiStatus(API_STATUS.SUCCESS)
        } catch (err) {
          setApiStatus(API_STATUS.ERROR, err.message)
        }
      }
    
      const updateTaskKanbanStatus = async (taskId, kanbanColumnId) => {
        try {
          // Optimistic update
          const task = oppmData.tasks.find(t => t.id === taskId)
          const oldColumnId = task.kanbanColumnId
          if (task) {
            task.kanbanColumnId = kanbanColumnId
          }
    
          await apiServices.kanban.updateTaskKanbanStatus(taskId, kanbanColumnId)
        } catch (err) {
          // Revert on error
          const task = oppmData.tasks.find(t => t.id === taskId)
          if (task) {
            task.kanbanColumnId = oldColumnId
          }
          setApiStatus(API_STATUS.ERROR, err.message)
        }
      }
    
      return {
        oppmData,
        kanbanColumns,
        isLoading,
        error,
        initializeData,
        updateTaskKanbanStatus,
      }
    }
    
  2. 更新主應用程式

    // src/main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    import { setupDevMocks } from './utils/devMocks'
    
    if (import.meta.env.DEV) {
      setupDevMocks()
    }
    
    createApp(App).mount('#oppm-app')
    
  3. 開發環境模擬設定

    // src/utils/devMocks.js
    export const setupDevMocks = () => {
      if (import.meta.env.DEV && typeof window !== 'undefined') {
        // 模擬 Serenity 的 Q 物件
        window.Q = window.Q || {
          Authorization: {
            username: 'Developer',
            antiForgeryToken: 'dev-csrf-token-' + Date.now(),
            hasPermission: (p) => true,
          },
          notifyInfo: (msg) => console.log('INFO:', msg),
          notifySuccess: (msg) => console.log('SUCCESS:', msg),
          notifyWarning: (msg) => console.warn('WARNING:', msg),
          notifyError: (msg) => console.error('ERROR:', msg),
        }
    
        // 模擬 CSRF Token 隱藏欄位
        if (!document.querySelector('input[name="__RequestVerificationToken"]')) {
          const tokenInput = document.createElement('input')
          tokenInput.type = 'hidden'
          tokenInput.name = '__RequestVerificationToken'
          tokenInput.value = window.Q.Authorization.antiForgeryToken
          document.body.appendChild(tokenInput)
        }
      }
    }
    

八、測試和部署流程

  1. 開發環境測試

    # 1. 啟動 Serenity 後端開發伺服器
    
    # 2. 啟動 Vue 開發伺服器(帶 API 代理)
    npm run dev
    
    # 3. 訪問 http://localhost:3000 進行測試
    
  2. 生產環境部署

    # 1. 建立 Vue 生產版本
    npm run build
    
    # 2. 複製到 Serenity 專案
    npm run copy-to-serenity
    
    # 3. 部署整個 Serenity 專案
    
  3. Serenity 頁面整合

    @{
        ViewData["Title"] = "OPPM 看板";
    }
    
    <div id="oppm-app">
        <p>Loading OPPM Module...</p>
    </div>
    
    @Html.AntiForgeryToken()
    
    @section Head {
        <link rel="stylesheet" href="~/Content/dist/assets/index.css" />
    }
    
    @section Scripts {
        <script type="module" src="~/Content/dist/assets/index.js"></script>
    }
    

九、總結

這個完整的整合方案提供了:

  • 環境隔離: 開發和生產環境的完全分離與配置。

  • 安全性: 完整的 CSRF Token 處理機制,防止跨站請求偽造。

  • 類型安全: 透過 C# 的強類型和前端的參數驗證確保數據正確性。

  • 錯誤處理: 統一的錯誤處理和與 Serenity 風格一致的用戶通知系統。

  • 可維護性: 基於服務的清晰代碼結構,易於維護和擴展。

  • 整合性: 透過整合工具,讓 Vue 應用能無縫利用 Serenity 的用戶認證、權限和本地化等功能。

透過這套方案,您可以在享受 Vue 3 現代開發體驗的同時,完全整合到現有的 C# Serenity 專案中,實現前後端的無縫協作。